Explora patrones avanzados de la API de Contexto de React, como componentes compuestos, contextos dinámicos y técnicas de rendimiento para gestionar estados complejos.
Patrones Avanzados de la API de Contexto de React para la Gestión de Estado
La API de Contexto de React proporciona un mecanismo poderoso para compartir el estado a través de tu aplicación sin necesidad de pasar props manualmente a través de múltiples niveles (prop drilling). Aunque su uso básico es sencillo, aprovechar todo su potencial requiere comprender patrones avanzados que pueden manejar escenarios complejos de gestión de estado. Este artículo explora varios de estos patrones, ofreciendo ejemplos prácticos y conocimientos aplicables para elevar tu desarrollo con React.
Comprendiendo las Limitaciones de la API de Contexto Básica
Antes de sumergirnos en los patrones avanzados, es crucial reconocer las limitaciones de la API de Contexto básica. Aunque es adecuada para estados simples y accesibles globalmente, puede volverse difícil de manejar e ineficiente para aplicaciones complejas con estados que cambian con frecuencia. Cada componente que consume un contexto se vuelve a renderizar cada vez que el valor del contexto cambia, incluso si el componente no depende de la parte específica del estado que se actualizó. Esto puede llevar a cuellos de botella en el rendimiento.
Patrón 1: Componentes Compuestos con Contexto
El patrón de Componentes Compuestos mejora la API de Contexto al crear un conjunto de componentes relacionados que comparten estado y lógica implícitamente a través de un contexto. Este patrón promueve la reutilización y simplifica la API para los consumidores. Esto permite encapsular lógica compleja con una implementación simple.
Ejemplo: Un Componente de Pestañas (Tab)
Ilustremos esto con un componente de Pestañas (Tab). En lugar de pasar props a través de múltiples capas, los componentes Tab
se comunican implícitamente a través de un contexto compartido.
// TabContext.js
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface TabContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabContext = createContext(undefined);
interface TabProviderProps {
children: ReactNode;
defaultTab: string;
}
export const TabProvider: React.FC = ({ children, defaultTab }) => {
const [activeTab, setActiveTab] = useState(defaultTab);
const value: TabContextType = {
activeTab,
setActiveTab,
};
return {children} ;
};
export const useTabContext = () => {
const context = useContext(TabContext);
if (!context) {
throw new Error('useTabContext must be used within a TabProvider');
}
return context;
};
// TabList.js
import React, { ReactNode } from 'react';
interface TabListProps {
children: ReactNode;
}
export const TabList: React.FC = ({ children }) => {
return {children};
};
// Tab.js
import React, { ReactNode } from 'react';
import { useTabContext } from './TabContext';
interface TabProps {
label: string;
children: ReactNode;
}
export const Tab: React.FC = ({ label, children }) => {
const { activeTab, setActiveTab } = useTabContext();
const isActive = activeTab === label;
const handleClick = () => {
setActiveTab(label);
};
return (
);
};
// TabPanel.js
import React, { ReactNode } from 'react';
import { useTabContext } from './TabContext';
interface TabPanelProps {
label: string;
children: ReactNode;
}
export const TabPanel: React.FC = ({ label, children }) => {
const { activeTab } = useTabContext();
const isActive = activeTab === label;
return (
{isActive && children}
);
};
// Uso
import { TabProvider, TabList, Tab, TabPanel } from './components/Tabs';
function App() {
return (
Tab 1
Tab 2
Tab 3
Content for Tab 1
Content for Tab 2
Content for Tab 3
);
}
export default App;
Beneficios:
- API simplificada para los consumidores: Los usuarios solo necesitan preocuparse por
Tab
,TabList
yTabPanel
. - Estado compartido implícito: Los componentes acceden y actualizan automáticamente el estado compartido.
- Reutilización mejorada: El componente
Tab
se puede reutilizar fácilmente en diferentes contextos.
Patrón 2: Contextos Dinámicos
En algunos escenarios, es posible que necesites diferentes valores de contexto según la posición del componente en el árbol de componentes u otros factores dinámicos. Los contextos dinámicos te permiten crear y proporcionar valores de contexto que varían según condiciones específicas.
Ejemplo: Temas con Contextos Dinámicos
Considera un sistema de temas en el que deseas proporcionar diferentes temas según las preferencias del usuario o la sección de la aplicación en la que se encuentren. Podemos hacer un ejemplo simplificado con un tema claro y oscuro.
// ThemeContext.js
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface Theme {
background: string;
color: string;
}
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const defaultTheme: Theme = {
background: 'white',
color: 'black'
};
const darkTheme: Theme = {
background: 'black',
color: 'white'
};
const ThemeContext = createContext({
theme: defaultTheme,
toggleTheme: () => {}
});
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC = ({ children }) => {
const [isDarkTheme, setIsDarkTheme] = useState(false);
const theme = isDarkTheme ? darkTheme : defaultTheme;
const toggleTheme = () => {
setIsDarkTheme(!isDarkTheme);
};
const value: ThemeContextType = {
theme,
toggleTheme,
};
return {children} ;
};
export const useTheme = () => {
return useContext(ThemeContext);
};
// Uso
import { useTheme, ThemeProvider } from './ThemeContext';
function MyComponent() {
const { theme, toggleTheme } = useTheme();
return (
This is a themed component.
);
}
function App() {
return (
);
}
export default App;
En este ejemplo, el ThemeProvider
determina dinámicamente el tema basándose en el estado isDarkTheme
. Los componentes que usan el hook useTheme
se volverán a renderizar automáticamente cuando cambie el tema.
Patrón 3: Contexto con useReducer para Estado Complejo
Para gestionar lógica de estado compleja, combinar la API de Contexto con useReducer
es un enfoque excelente. useReducer
proporciona una forma estructurada de actualizar el estado en función de acciones, y la API de Contexto te permite compartir este estado y la función de despacho (dispatch) a través de tu aplicación.
Ejemplo: Una Lista de Tareas Sencilla
// TodoContext.js
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
}
type TodoAction =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'DELETE_TODO'; id: number };
interface TodoContextType {
state: TodoState;
dispatch: React.Dispatch;
}
const initialState: TodoState = {
todos: [],
};
const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.text, completed: false }],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.id),
};
default:
return state;
}
};
const TodoContext = createContext(undefined);
interface TodoProviderProps {
children: ReactNode;
}
export const TodoProvider: React.FC = ({ children }) => {
const [state, dispatch] = useReducer(todoReducer, initialState);
const value: TodoContextType = {
state,
dispatch,
};
return {children} ;
};
export const useTodo = () => {
const context = useContext(TodoContext);
if (!context) {
throw new Error('useTodo must be used within a TodoProvider');
}
return context;
};
// Uso
import { useTodo, TodoProvider } from './TodoContext';
function TodoList() {
const { state, dispatch } = useTodo();
return (
{state.todos.map((todo) => (
-
{todo.text}
))}
);
}
function AddTodo() {
const { dispatch } = useTodo();
const [text, setText] = React.useState('');
const handleSubmit = (e) => {
e.preventDefault();
dispatch({ type: 'ADD_TODO', text });
setText('');
};
return (
);
}
function App() {
return (
);
}
export default App;
Este patrón centraliza la lógica de gestión del estado dentro del reducer, lo que facilita el razonamiento y las pruebas. Los componentes pueden despachar acciones para actualizar el estado sin necesidad de gestionar el estado directamente.
Patrón 4: Actualizaciones de Contexto Optimizadas con `useMemo` y `useCallback`
Como se mencionó anteriormente, una consideración clave de rendimiento con la API de Contexto son los re-renders innecesarios. Usar useMemo
y useCallback
puede prevenir estos re-renders al asegurar que solo se actualicen las partes necesarias del valor del contexto y que las referencias de las funciones permanezcan estables.
Ejemplo: Optimizando un Contexto de Tema
// OptimizedThemeContext.js
import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react';
interface Theme {
background: string;
color: string;
}
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const defaultTheme: Theme = {
background: 'white',
color: 'black'
};
const darkTheme: Theme = {
background: 'black',
color: 'white'
};
const ThemeContext = createContext({
theme: defaultTheme,
toggleTheme: () => {}
});
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC = ({ children }) => {
const [isDarkTheme, setIsDarkTheme] = useState(false);
const theme = isDarkTheme ? darkTheme : defaultTheme;
const toggleTheme = useCallback(() => {
setIsDarkTheme(!isDarkTheme);
}, [isDarkTheme]);
const value: ThemeContextType = useMemo(() => ({
theme,
toggleTheme,
}), [theme, toggleTheme]);
return {children} ;
};
export const useTheme = () => {
return useContext(ThemeContext);
};
Explicación:
useCallback
memoiza la funcióntoggleTheme
. Esto asegura que la referencia de la función solo cambie cuandoisDarkTheme
cambie, evitando re-renders innecesarios de componentes que solo dependen de la funcióntoggleTheme
.useMemo
memoiza el valor del contexto. Esto asegura que el valor del contexto solo cambie cuando eltheme
o la funcióntoggleTheme
cambien, previniendo aún más los re-renders innecesarios.
Sin useCallback
, la función toggleTheme
se recrearía en cada renderizado del ThemeProvider
, lo que provocaría que el value
cambiara y desencadenara re-renders en cualquier componente consumidor, incluso si el tema en sí no hubiera cambiado. useMemo
asegura que solo se cree un nuevo value
cuando sus dependencias (theme
o toggleTheme
) cambien.
Patrón 5: Selectores de Contexto
Los selectores de contexto permiten a los componentes suscribirse solo a partes específicas del valor del contexto. Esto evita re-renders innecesarios cuando otras partes del contexto cambian. Se pueden usar bibliotecas como `use-context-selector` o implementaciones personalizadas para lograr esto.
Ejemplo Usando un Selector de Contexto Personalizado
// useCustomContextSelector.js
import { useContext, useState, useRef, useEffect } from 'react';
function useCustomContextSelector(
context: React.Context,
selector: (value: T) => S
): S {
const value = useContext(context);
const [selected, setSelected] = useState(() => selector(value));
const latestSelector = useRef(selector);
latestSelector.current = selector;
useEffect(() => {
let didUnmount = false;
let lastSelected = selected;
const subscription = () => {
if (didUnmount) {
return;
}
const nextSelected = latestSelector.current(value);
if (!Object.is(lastSelected, nextSelected)) {
lastSelected = nextSelected;
setSelected(nextSelected);
}
};
// Normalmente te suscribirías a los cambios del contexto aquí. Como este es un ejemplo
// simplificado, simplemente llamaremos a la suscripción de inmediato para inicializar.
subscription();
return () => {
didUnmount = true;
// Anula la suscripción a los cambios del contexto aquí, si corresponde.
};
}, [value]); // Vuelve a ejecutar el efecto cada vez que el valor del contexto cambie
return selected;
}
export default useCustomContextSelector;
// ThemeContext.js (Simplificado por brevedad)
import React, { createContext, useState, ReactNode } from 'react';
interface Theme {
background: string;
color: string;
}
interface ThemeContextType {
theme: Theme;
setTheme: (newTheme: Theme) => void;
}
const ThemeContext = createContext(undefined);
interface ThemeProviderProps {
children: ReactNode;
initialTheme: Theme;
}
export const ThemeProvider: React.FC = ({ children, initialTheme }) => {
const [theme, setTheme] = useState(initialTheme);
const value: ThemeContextType = {
theme,
setTheme
};
return {children} ;
};
export const useThemeContext = () => {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error("useThemeContext must be used within a ThemeProvider");
}
return context;
};
export default ThemeContext;
// Uso
import useCustomContextSelector from './useCustomContextSelector';
import ThemeContext, { ThemeProvider, useThemeContext } from './ThemeContext';
function BackgroundComponent() {
const background = useCustomContextSelector(ThemeContext, (context) => context.theme.background);
return Background;
}
function ColorComponent() {
const color = useCustomContextSelector(ThemeContext, (context) => context.theme.color);
return Color;
}
function App() {
const { theme, setTheme } = useThemeContext();
const toggleTheme = () => {
setTheme({ background: theme.background === 'white' ? 'black' : 'white', color: theme.color === 'black' ? 'white' : 'black' });
};
return (
);
}
export default App;
En este ejemplo, BackgroundComponent
solo se vuelve a renderizar cuando la propiedad background
del tema cambia, y ColorComponent
solo se vuelve a renderizar cuando la propiedad color
cambia. Esto evita re-renders innecesarios cuando cambia todo el valor del contexto.
Patrón 6: Separando Acciones del Estado
Para aplicaciones más grandes, considera separar el valor del contexto en dos contextos distintos: uno para el estado y otro para las acciones (funciones de despacho). Esto puede mejorar la organización del código y la capacidad de prueba.
Ejemplo: Lista de Tareas con Contextos de Estado y Acción Separados
// TodoStateContext.js
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
}
const initialState: TodoState = {
todos: [],
};
const TodoStateContext = createContext(initialState);
interface TodoStateProviderProps {
children: ReactNode;
}
export const TodoStateProvider: React.FC = ({ children }) => {
const [state] = useReducer(todoReducer, initialState);
return {children} ;
};
export const useTodoState = () => {
return useContext(TodoStateContext);
};
// TodoActionContext.js
import React, { createContext, useContext, Dispatch, ReactNode } from 'react';
type TodoAction =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: number }
| { type: 'DELETE_TODO'; id: number };
const TodoActionContext = createContext | undefined>(undefined);
interface TodoActionProviderProps {
children: ReactNode;
}
export const TodoActionProvider: React.FC = ({children}) => {
const [, dispatch] = useReducer(todoReducer, initialState);
return {children} ;
};
export const useTodoDispatch = () => {
const dispatch = useContext(TodoActionContext);
if (!dispatch) {
throw new Error('useTodoDispatch must be used within a TodoActionProvider');
}
return dispatch;
};
// todoReducer.js
export const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.text, completed: false }],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map((todo) =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
),
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter((todo) => todo.id !== action.id),
};
default:
return state;
}
};
// Uso
import { useTodoState, TodoStateProvider } from './TodoStateContext';
import { useTodoDispatch, TodoActionProvider } from './TodoActionContext';
function TodoList() {
const state = useTodoState();
return (
{state.todos.map((todo) => (
-
{todo.text}
))}
);
}
function TodoActions({ todo }) {
const dispatch = useTodoDispatch();
return (
<>
>
);
}
function AddTodo() {
const dispatch = useTodoDispatch();
const [text, setText] = React.useState('');
const handleSubmit = (e) => {
e.preventDefault();
dispatch({ type: 'ADD_TODO', text });
setText('');
};
return (
);
}
function App() {
return (
);
}
export default App;
Esta separación permite que los componentes solo se suscriban al contexto que necesitan, reduciendo los re-renders innecesarios. También facilita las pruebas unitarias del reducer y de cada componente de forma aislada. Además, el orden en que se envuelven los proveedores es importante. El ActionProvider
tiene que envolver al StateProvider
.
Mejores Prácticas y Consideraciones
- El contexto no debe reemplazar todas las bibliotecas de gestión de estado: Para aplicaciones muy grandes y complejas, las bibliotecas de gestión de estado dedicadas como Redux o Zustand podrían seguir siendo una mejor opción.
- Evita la sobre-contextualización: No todas las piezas de estado necesitan estar en un contexto. Usa el contexto con prudencia para estados verdaderamente globales o ampliamente compartidos.
- Pruebas de rendimiento: Mide siempre el impacto en el rendimiento de tu uso del contexto, especialmente cuando se trata de estados que se actualizan con frecuencia.
- División de código (Code Splitting): Al usar la API de contexto, considera dividir tu aplicación en fragmentos más pequeños. Esto es especialmente importante cuando un pequeño cambio en el estado hace que una gran parte de la aplicación se vuelva a renderizar.
Conclusión
La API de Contexto de React es una herramienta versátil para la gestión de estado. Al comprender y aplicar estos patrones avanzados, puedes gestionar eficazmente estados complejos, optimizar el rendimiento y construir aplicaciones de React más mantenibles y escalables. Recuerda elegir el patrón adecuado para tus necesidades específicas y considerar cuidadosamente las implicaciones de rendimiento de tu uso del contexto.
A medida que React evoluciona, también lo harán las mejores prácticas en torno a la API de Contexto. Mantenerse informado sobre nuevas técnicas y bibliotecas te asegurará estar equipado para manejar los desafíos de la gestión de estado del desarrollo web moderno. Considera explorar patrones emergentes como el uso de contexto con señales (signals) para una reactividad aún más granular.